/*
 * @Description: 上传
 * @Autor: HuiSir<github.com/huisir001>
 * @Date: 2022-07-21 11:09:31
 * @LastEditTime: 2022-08-02 11:22:10
 */
import fs from 'node:fs/promises'
import path from 'node:path'
import { v4 as uuidv4 } from 'uuid'
import {
    useResult,
    useClassDecorators,
    useMethodDecorators,
    useParameterDecorators,
    type KoaControl
} from 'koa-control'
import FileModel from '../models/File.js'
import ChunkModel from '../models/Chunk.js'
import { UploadDto, FileStatusDto, SlicedUploadDto } from './dto/index.js'
import contentTypes from '../helpers/contentType.js'

const { succ } = useResult()
const { Controller } = useClassDecorators()
const { Get, Post, Bind } = useMethodDecorators()
const { Params, Raw, Files } = useParameterDecorators()

// 有hash（md5）的情况下，上传前先通过各分片hash查询是否已有文件以及分片上传情况，若已有上传成功的分片，则无需上传该分片
// 分片上传只需上传未传分片，这样才能实现断点续传。而不是每次上传所有分片
// 请求只要有文件不论后端存不存都会占用内存资源，影响请求时间
// 所以不必在传文件时在后端判断文件是否上传过。只需要提前获知文件上传情况（通过md5）就可以。

@Controller()
export default class Upload {
    /**
     * @description: 普通上传（前端不计算文件hash，不支持秒传和断点续传）
     * @param {KoaControl.Files} file 文件
     * @return {*}
     * @author: HuiSir
     */
    @Post('/upload')
    @Bind(UploadDto)
    async upload(@Files('file') file: UploadDto['file']): Promise<any> {
        // 这里区分是多文件还是单文件，多文件存多条
        let data: any
        if (Array.isArray(file)) {
            for await (let item of file) {
                const chunkId = uuidv4()
                await fileRename(item, chunkId)
                item.src = getSrc(item.extName, chunkId)
            }
            data = await FileModel.createMany(file)
        } else {
            const chunkId = uuidv4()
            await fileRename(file as any, chunkId);
            (file as any).src = getSrc((file as any).extName, chunkId)
            data = await FileModel.create({ ...file })
        }
        return succ({ data, msg: '上传成功' })
    }

    /**
     * @description: 查询分片上传状态
     * @param {string} md5 文件md5值
     * @return {*}
     * @author: HuiSir
     */
    @Get('/upload/fileStatus/:md5')
    @Bind(FileStatusDto)
    async fileStatus(@Params('md5') md5: string): Promise<any> {
        // 先查询是否已有此文件
        const file = await FileModel.findOne({ md5 })

        // status = 0 未开始上传
        // status = 1 已上传完毕
        // status = 2 已上传一部分，未上传完毕

        if (file) {
            return succ({ data: { status: 1, file } })
        } else {
            // page = -1 查所有
            const chunks = await ChunkModel.find({ md5 }, { page: -1 })
            return succ({ data: chunks.length ? { status: 2, chunks } : { status: 0 } })
        }
    }

    /**
     * @description: 分片上传（前端需计算文件hash，可断点续传、秒传）
     * @return {*}
     * @author: HuiSir
     * 前端将大文件进行分片，例如一个50MB的文件，分成10片，每个片5MB。
     * 然后发10个HTTP请求，将这10个分片数据发送给后端，
     * 后端根据分片的下标和Size来合并分片为完整文件。
     * ps:这里不直接将分片流写入文件，而是分片保存后在将保存的片段合并，虽然进行了两次文件写入，但实现了续传功能
     */
    @Post('/upload/chunk')
    @Bind(SlicedUploadDto)
    async slicedUpload(@Files('chunk') chunk: SlicedUploadDto['chunk'], @Raw() raw: SlicedUploadDto): Promise<any> {
        const { md5, chunkNum } = raw

        // 先查询是否已有此文件
        const currfile = await FileModel.findOne({ md5 })

        // 已有此文件
        if (currfile) {
            // 删除片段
            await fs.unlink(chunk.path)
            // 返回
            return succ({ data: currfile, msg: '上传成功' })
        }

        // 重命名
        await fileRename(chunk, `${md5}-${chunkNum}`)

        // 查询是否已有此分片
        const currChunk = await ChunkModel.findOne({ path: chunk.path })

        if (currChunk) {
            return succ('文件片段上传成功')
        }

        try {

            // 存储分片
            await ChunkModel.create({ chunkSize: chunk.size, path: chunk.path, ...raw })

            // 查询分片表
            const chunks: any[] = await ChunkModel.find({ md5 }, { page: -1, sort: 'chunkNum' })

            // 上传完毕，执行合并
            if (chunks.length == raw.chunkCount) {// chunkCount可能为字符串，使用`==`也可以判断
                const { name, extName, type } = chunk
                const chunkId = uuidv4()

                // newname
                const newName = chunkId + extName

                // 写入路径
                const filepath = path.join(path.dirname(chunk.path), newName)

                // 文件写入
                const fd = await fs.open(filepath, 'a+')
                let size = 0

                // 上传完毕，合并片段
                for await (let item of chunks) {
                    const buffer = await fs.readFile(item.path)
                    await fd.appendFile(buffer)
                    size += buffer.length
                }

                // size 计算
                const gb = Number((size / 1024 / 1024 / 1024).toFixed(2)),
                    mb = Number((size / 1024 / 1024).toFixed(2)),
                    kb = Number((size / 1024).toFixed(2));

                const unitSize = gb > 1 ? `${gb} GB` : mb > 1 ? `${mb} MB` : `${kb} KB`

                // 合并完成，存file表
                const data = await FileModel.create({
                    name,
                    extName,
                    newName,
                    size,
                    unitSize,
                    type,
                    chunkId,
                    md5,
                    description: null,
                    lastModified: Date.now(),
                    path: filepath,
                    src: getSrc(extName, chunkId),
                })

                // 存表完成再删除分段缓存，否则若存表失败无法找回分段
                for await (let item of chunks) {
                    await fs.unlink(item.path)
                }

                // 删表
                await ChunkModel.removeMany('id', chunks.map(item => item.id).join(','))

                // 返回
                return succ({ data, msg: '上传成功' })
            }

            return succ('文件片段上传成功')

        } catch (error) {
            // 删除已上传文件
            await fs.unlink(chunk.path)

            throw error
        }
    }
}

/**
 * 重命名文件且重置参数
 */
async function fileRename(file: KoaControl.File, chunkId: string) {
    file.chunkId = chunkId
    file.newName = file.chunkId + file.extName
    const newPath = path.join(path.dirname(file.path), file.newName)
    try {
        await fs.rename(file.path, newPath)
    } catch (error) {
        await fs.unlink(file.path)
        return Promise.reject(error)
    }
    file.path = newPath
    file.src = path.join(path.dirname(file.src), file.newName)
    file.lastModified = Date.now()
}

/**
 * src（前台访问路径）
 * 这里只有 'image' | 'video' | 'audio' 三种类型才提供直接访问，其他文件src为null
 */
function getSrc(extName: string, chunkId: string): string | null {
    const contentType = contentTypes[extName]
    const type = contentType.split('/')[0]
    if (['image', 'video', 'audio'].includes(type)) {
        return `/${type}/${chunkId}`
    } else {
        return null
    }
}